47 — 开场与加载进度

Three.js Journey · Bruno Simon · 难度:简单

47 — 开场与加载进度 课程海报

引言

到目前为止,我们页面上只是一个 WebGL canvas,当内容准备就绪后就会显示出来。

在本课中,我们将学习如何添加一个非常简单的 loader,它由一根进度条组成,会在 assets 加载时逐渐填满。整个 scene 将是黑色的,只有在所有内容加载完成后才会伴随着优雅的淡入效果显示出来。

对于 loader,我们将使用 HTML 和 CSS。这是一个绝佳的机会,来看看如何将 HTML 与 WebGL 结合起来。

准备工作

我们的初始项目包含我们在 Realistic Render 课程中对 Flight Helmet 所做的内容。

覆盖层

首先,我们需要一种让 scene 淡出的方式。有很多方法可以做到这一点。我们可以对 <canvas> 的 CSS opacity 进行动画。我们也可以在 <canvas> 上方放一个黑色的 <div>,并对其 CSS opacity 进行动画。但相反,我们要把所有内容都保留在 WebGL 内部,绘制一个覆盖整个渲染的黑色矩形,并在需要时将它淡出。

问题在于:我们如何在 camera 前方绘制一个矩形?以我们目前掌握的知识,我们可以创建一个 plane 并把它直接放进 camera 而不是 scene 中,这应该能正常工作,因为 camera 继承自 Object3D,但这看起来有点像打补丁。

相反,我们将绘制一个不遵循位置、透视和投影规则的 plane,这样它就会被直接绘制在视图前方。别担心;这比你想象的要简单。

基础 plane

首先,我们将从一个经典的 plane 开始。

创建一个 PlaneGeometry、一个 MeshBasicMaterial 和一个 Mesh。然后把它们全部添加到 scene 中:

/**
 * Overlay
 */
const overlayGeometry = new THREE.PlaneGeometry(1, 1, 1, 1)
const overlayMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 })
const overlay = new THREE.Mesh(overlayGeometry, overlayMaterial)
scene.add(overlay)

这个 plane 应该能从头盔的另一侧被看到。

填满渲染

我们现在希望这个 plane 始终位于 camera 前方。我们希望无论 camera 位置如何,它都能填满渲染。为此,我们将使用一个 ShaderMaterial。

将 MeshBasicMaterial 替换为一个 ShaderMaterial,并编写我们之前用 vertexShader 属性和 fragmentShader 属性学过的默认 shaders。你可以尝试凭记忆来完成,但即使做不到也不要沮丧。这需要时间:

const overlayMaterial = new THREE.ShaderMaterial({
    vertexShader: `
        void main()
        {
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
    `,
    fragmentShader: `
        void main()
        {
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
        }
    `
})

你应该会得到相同的结果,但这一次,我们可以控制 shaders 了。

要让 plane 填满渲染,我们需要不应用那些矩阵:

const overlayMaterial = new THREE.ShaderMaterial({
    vertexShader: `
        void main()
        {
            gl_Position = vec4(position, 1.0);
        }
    `,
    fragmentShader: `
        void main()
        {
            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
        }
    `
})

现在你会得到一个位于中央的大矩形。它的位置、camera 位置、视场角或其他任何东西都无法改变它,因为没有任何矩阵被使用。

plane 顶点的坐标从 -0.5 到 +0.5,因为我们的 plane 尺寸为 1。

这样剥离后的 vertex shader,会按照指定的坐标直接在屏幕上绘制三角形,不考虑其他任何东西。我们可以通过将 wireframe 属性设置为 true 来看到这些三角形:

const overlayMaterial = new THREE.ShaderMaterial({
    wireframe: true,
    // ...
})

注释掉或删除 wireframe。

要获得一个更大的矩形,我们需要坐标从 -1 到 +1。要做到这一点,将 PlaneGeometry 的尺寸翻倍:

const overlayGeometry = new THREE.PlaneGeometry(2, 2, 1, 1)

这个矩形现在填满了整个渲染。

颜色与 alpha

假设我们不要这个红色,而是要黑色。

修改 gl_FragColor:

const overlayMaterial = new THREE.ShaderMaterial({
    // ...
    fragmentShader: `
        void main()
        {
            gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
        }
    `
})

一切看起来都是黑色的。

现在,我们希望能够控制 alpha。如你所知,它就是 gl_FragColor 的第四个值。

将这个第四个参数设置为 0.5,看看是否生效:

const overlayMaterial = new THREE.ShaderMaterial({
    // ...
    fragmentShader: `
        void main()
        {
            gl_FragColor = vec4(0.0, 0.0, 0.0, 0.5);
        }
    `
})

遗憾的是,一切仍然是完全黑色的,这是因为我们忘了一件很容易忘记的事。我们需要将 ShaderMaterial 上的 transparent 属性设置为 true:

const overlayMaterial = new THREE.ShaderMaterial({
    transparent: true,
    // ...
})

整个 scene 应该看起来更暗了。

Uniform

现在我们已经设置好了 overlay,我们需要一种控制 alpha 值的方法。我们将使用一个 uniform。

像之前一样添加一个 uAlpha uniform:

const overlayMaterial = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        uAlpha: { value: 0.5 }
    },
    // ...
})

然后在 fragmentShader 中使用它,替代原始的 0.5:

const overlayMaterial = new THREE.ShaderMaterial({
    // ...
    fragmentShader: `
        uniform float uAlpha;

        void main()
        {
            gl_FragColor = vec4(0.0, 0.0, 0.0, uAlpha);
        }
    `
})

你应该会得到相同的结果,但这一次我们可以通过 JavaScript 直接控制 alpha,使用 uAlpha uniform。

让我们将该 uniform 的值改为 1,以便从全黑的屏幕开始。

const overlayMaterial = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        uAlpha: { value: 0.5 }
    },
    // ...
})

加载

现在我们的 overlay 已经准备好被动画,我们想知道所有内容何时加载完成。

虽然 scene 中只有一个模型,但我们实际上加载了许多 assets。我们加载了构成环境贴图的 6 张图片、模型的 geometries,以及模型中使用的所有 textures。

为了加载这些 assets,我们使用了 GLTFLoader 和一个 CubeTextureLoader。两者都可以接收一个 LoadingManager 作为参数。那个 LoadingManager ——正如我们在课程开头看到的那样——可以用来随时了解全局加载进度。

实例化一个 LoadingManager,并在 GLTFLoader 和 CubeTextureLoader 中使用它:

/**
 * Loaders
 */
const loadingManager = new THREE.LoadingManager()
const gltfLoader = new GLTFLoader(loadingManager)
const cubeTextureLoader = new THREE.CubeTextureLoader(loadingManager)

应该没有任何变化,但我们现在可以向 LoadingManager 传入两个函数。

第一个函数会在所有内容加载完成时被触发,第二个函数会在加载进行时被触发。

添加这两个函数,并带有以下参数:

const loadingManager = new THREE.LoadingManager(
    // Loaded
    () =>
    {
        console.log('loaded')
    },

    // Progress
    () =>
    {
        console.log('progress')
    }
)

你应该会在日志中看到多个 "progress",并在最后看到一个 "loaded"。

progress 函数稍后会有用。目前,我们只需要 loaded 函数。

动画

要淡出 overlay,我们需要一种对 uAlpha uniform 值进行动画的方法。虽然这有点大材小用,但仅仅为此,我们将像课程开头那样使用 GSAP 库。

首先,在终端中,使用 npm install --save gsap@3.12 安装 gsap 库——如果你停止了服务器,请重新启动它。

现在 dependencies 中有了 gsap,我们可以导入它:

import { gsap } from 'gsap'

最后,我们可以在 loaded 函数中使用它来对 uAlpha uniform 值进行动画:

const loadingManager = new THREE.LoadingManager(
    // Loaded
    () =>
    {
        gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0 })
    },

    // ...
)
lesson demo video preview

所有内容加载完成后,overlay 应该会优雅地淡出。

加载条

这些 assets 相当大,整个项目大约有 50MB。这个模型可以做一些优化,但这不是本课的主题。这是一个绝佳的机会,来通过一个漂亮的加载条观察 assets 的加载过程。

模拟正常带宽

问题在于我们是在本地测试,所有东西加载得都飞快。如果你使用的是 Chrome,你可以模拟更慢的带宽。

以下说明针对 Chrome,但所有这些功能在 Firefox 中也都有。

在 Developer Tools 面板中,转到 Network 选项卡。

首先,勾选 Disable cache。

这将强制 assets 在无缓存的情况下加载,就像你第一次访问网站一样。

然后,我们需要一种限制带宽的方法。为此,点击带有 Online 值的下拉菜单。

在这里,我们可以选择各种限速值。遗憾的是,可用的选项并不适合这个项目。

要添加自定义值,点击 Add:

在应该会打开的面板中,点击 Add custom profile...,选择一个像 "Pretty fast" 这样的名称,并将 Download 值设置为 100000。

这个值的含义并不那么重要,只要我们有时间看到 assets 加载即可。对于一个典型的项目,你应该创建一个更低的值来模拟 ADSL 连接。

保存它,关闭面板,然后在下拉菜单中选择这个值。

现在网络会模拟一个相当快的带宽。

如果你重新加载,你会看到现在需要等待大约 3 秒钟,overlay 才会淡出。

创建 HTML 加载条

现在我们可以测试加载进度了,是时候添加加载条了。这一次,为了本课的目的,我们将用 HTML 添加这根条,但我们本可以像处理 overlay 那样创建一个带有 shader 并进行动画的新 plane。

打开 /src/index.html 文件,在 <canvas> 之后紧接着添加一个带有 loading-bar class 的 <div>:

<!-- ... -->
<body>
    <canvas class="webgl"></canvas>
    <div class="loading-bar"></div>
</body>
</html>

然后,在 /src/style.css 文件中,定位这根条,让它占据屏幕中间的整个宽度:

.loading-bar
{
    position: absolute;
    top: 50%;
    width: 100%;
    height: 2px;
    background: #ffffff;
}

这是经典的 CSS,我们就不再解释了。

我们希望这根条从左侧开始填充。为此,我们将使用 transform CSS 属性对它进行缩放。

设置一个带有 scaleX 的 transform 属性,值为 0.3:

.loading-bar
{
    /* ... */
    transform: scaleX(0.3);
}

缩放生效了;遗憾的是,这根条似乎是从中间缩放的。要改变这一点,使用 transform-origin 属性:

.loading-bar
{
    /* ... */
    transform: scaleX(0.3);
    transform-origin: top left;
}

这根条应该保持在左侧。

最后,将 scaleX 的值设置为 0:

.loading-bar
{
    /* ... */
    transform: scaleX(0);
    transform-origin: top left;
}

更新这根条

现在我们有了这根条,让我们回到 /src/script.js,在加载进行时对它进行动画。

要知道加载何时进行,我们可以使用传入 LoadingManager 的第二个函数:

const loadingManager = new THREE.LoadingManager(
    // ...

    // Progress
    () =>
    {
        console.log('progress')
    }
)

这个函数会在每次加载完一个 asset 时被触发,并且可以接收 3 个参数:

你可以测试这些值:

const loadingManager = new THREE.LoadingManager(
    // ...

    // Progress
    (itemUrl, itemsLoaded, itemsTotal) =>
    {
        console.log(itemUrl, itemsLoaded, itemsTotal)
    }
)

你应该会在日志中看到每个 asset 的加载情况。

现在,我们需要更新 scaleX 的值。为此,我们只需要更新 loading-bar 元素的样式。

首先,让我们获取那个元素。为此,我们将使用 document.querySelector:

const loadingBarElement = document.querySelector('.loading-bar')

这是经典的 JavaScript 技巧,用选择器获取一个元素——就像在 CSS 中一样。

在 progress 函数中,我们首先需要计算进度比例。这个值应该从没有 asset 加载时的 0 变化到所有 assets 都加载完成时的 1。

要计算这个比例,我们可以简单地将 itemsLoaded 除以 itemsTotal:

const loadingManager = new THREE.LoadingManager(
    // ...

    // Progress
    (itemUrl, itemsLoaded, itemsTotal) =>
    {
        const progressRatio = itemsLoaded / itemsTotal
        console.log(progressRatio)
    }
)

你应该会在日志中看到 progressRatio 从 0 变化到 1。

我们现在可以更新 loadingBarElement 的 transform:

const loadingManager = new THREE.LoadingManager(
    // ...

    // Progress
    (itemUrl, itemsLoaded, itemsTotal) =>
    {
        const progressRatio = itemsLoaded / itemsTotal
        loadingBarElement.style.transform = `scaleX(${progressRatio})`
    }
)
lesson demo video preview

这根条应该会根据加载进度填充。

不要忘记保持 Developer tools 打开,勾选 Disable cache,并将限速设置为 Pretty fast。

平滑动画

让我们通过平滑过渡让动画更流畅一些。有很多方法可以做到这一点,但最简单的解决方案是在 CSS 中添加一个 transition。

在 /src/style.css 文件中,为 transform 属性添加一个 0.5s 过渡:

.loading-bar
{
    /* ... */
    transition: transform 0.5s;
}
lesson demo video preview

你应该会得到相同的结果,但这根条上的动画更平滑。

隐藏这根条

有很多方法可以在所有内容加载完成后隐藏这根条,但为了让它更酷并保持流畅,我们将让这根条向右侧消失。

首先,在 /src/style.css 文件中,添加一个带有以下属性的新选择器:

.loading-bar.ended
{
    transform: scaleX(0);
    transform-origin: 100% 0;
    transition: transform 1.5s ease-in-out;
}

请确保不要在 .loading-bar 和 .ended 之间添加空格,这样这些属性只有在元素同时具有这两个 class 时才会生效。

这些属性只是将这根条缩放到 0,并将变换对齐到右侧,同时使用 ease-in-out 时间函数进行更长的过渡。

我们现在需要做的就是在所有内容加载完成时给元素添加 ended class。

我们可以在 loaded 函数中使用 classList.add(...) 方法来完成:

const loadingManager = new THREE.LoadingManager(
    // Loaded
    () =>
    {
        gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0, delay: 1 })

        loadingBarElement.classList.add('ended')
    },

    // ...
)

遗憾的是,这不会生效,因为 progress 函数仍然对元素应用了 scaleX。

我们只需要将 transform 设置为空字符串即可解决:

const loadingManager = new THREE.LoadingManager(
    // Loaded
    () =>
    {
        gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0, delay: 1 })

        loadingBarElement.classList.add('ended')
        loadingBarElement.style.transform = ''
    },

    // ...
)
lesson demo video preview

这根条应该如预期那样向右侧消失。动画看起来有点跳跃,这并不理想。

这有两个原因。首先,第一次渲染 scene 中的元素需要时间,计算机会卡顿片刻。其次,我们给这根条添加了 0.5s 过渡。这意味着当 loaded 函数被触发时,这根条还没有完成到终点的过渡。

要解决这些问题,我们可以让 JavaScript 使用 setTimeout(...) 在开始开场动画之前等待 0.5s:

const loadingManager = new THREE.LoadingManager(
    // Loaded
    () =>
    {
        window.setTimeout(() =>
        {
            gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0, delay: 1 })

            loadingBarElement.classList.add('ended')
            loadingBarElement.style.transform = ''
        }, 500)
    },

    // ...
)
lesson demo video preview

这根条现在消失得更平滑了。

就这样。你现在拥有了一个不太突兀的 intro,以及一个 HTML 加载条。